Skip to content

feat(text): bound SDF text layout cache with idle LRU eviction#55

Merged
chiefcll merged 3 commits into
mainfrom
feat/sdf-text-cache-lru
Jun 4, 2026
Merged

feat(text): bound SDF text layout cache with idle LRU eviction#55
chiefcll merged 3 commits into
mainfrom
feat/sdf-text-cache-lru

Conversation

@chiefcll
Copy link
Copy Markdown
Contributor

@chiefcll chiefcll commented Jun 4, 2026

Problem

The SDF text layout cache (layoutCache in SdfTextRenderer) is content-keyed (text + font + layout props) and shared across nodes — so identical strings (badges, labels) correctly reuse layout work. But it had no eviction: every distinct combination ever rendered stays in the map for the life of the JS context.

Node destroy() only releases the per-node caches (_sdfCache, _cachedLayout); it never touches the shared map. On a long-lived embedded session (set-top box, no page reload), browsing many entity pages with unique descriptions grows the cache monotonically. Each ~200-char description retains a glyph-layout array (~30 KB of JS heap — plain numbers, no GPU/texture/ImageData references) that is never freed.

Rough scale: ~1k distinct descriptions ≈ ~30 MB, ~10k ≈ ~300 MB.

Note: upstream lightning-js/renderer has the equivalent cache (renderInfoCache) and exports a clearCache() called from Stage.destroy() (full teardown only). This fork had dropped that hook entirely. Neither evicts mid-session — this PR adds proper mid-session bounding.

Changes

  • Configurable cap — new textLayoutCacheSize renderer option (default 250), plumbed through to the SDF/Canvas renderers via stage options.
  • True LRU — cache hits move the entry to most-recently-used; new cleanup() trims to the cap, evicting least-recently-used first. Cold entries (e.g. descriptions from pages you've navigated away from) age out automatically.
  • Idle evictionStage.cleanupTextRenderers() is called from the "entering idle" block in WebPlatform (beside shManager.cleanup()), so trimming runs once per idle transition and never competes with active rendering.
  • Interface + Canvas parity — added cleanup to the TextRenderer interface. Canvas implements it for uniformity, though its layoutCache is currently dead code (declared/cleared but never read/written), so it's inert today.

The cache is bounded purely by the LRU cap regardless of entry size — long strings simply age out as least-recently-used rather than being refused entry, so long strings that do repeat across nodes still benefit from caching.

Testing

  • pnpm build clean, pnpm test passes (4 new SDF cache tests), lint clean.
  • New unit tests (SdfTextRenderer.test.ts): identical-string reuse, long strings cached too (no length skip), LRU eviction order, and the under-cap no-op.
  • No visual regression test — rendering output is byte-identical; this is a memory/housekeeping change, not a visual feature.

Reviewer notes

  • Default cap of 250 is a judgment call; tune via the new textLayoutCacheSize option for content-dense UIs.
  • The dead Canvas layoutCache is a pre-existing latent issue left untouched here — candidate for a follow-up to either wire up or remove.

🤖 Generated with Claude Code

chiefcll and others added 3 commits June 4, 2026 16:54
The SDF text layout cache (`layoutCache` in SdfTextRenderer) is content-keyed
and shared across nodes, but had no eviction: every distinct text+font+layout
combination ever rendered stayed in the map for the life of the JS context.
On long-lived embedded sessions (no page reload), navigating many entity pages
with unique descriptions grows the cache monotonically — each ~200-char
description retains a glyph-layout array (~30KB) that is never freed, since
node destroy only releases per-node caches, not the shared map.

Changes:
- Add configurable `textLayoutCacheSize` renderer option (default 250),
  plumbed through to the SDF/Canvas renderers via stage options.
- Skip caching strings over 100 chars: long strings are almost always unique
  and set once, so they have a near-zero hit rate while being the largest
  entries. They still lay out and render normally; they just don't enter the
  cache. This removes the leak at its source.
- Make the cache a true LRU: cache hits move the entry to most-recently-used,
  and a new `cleanup()` trims to the cap evicting least-recently-used first.
- Run eviction on idle: Stage.cleanupTextRenderers() is called from the
  "entering idle" block in WebPlatform, so trimming never competes with active
  rendering.
- Add `cleanup` to the TextRenderer interface; Canvas implements it for parity
  (its layoutCache is currently dead code — declared/cleared but never
  read/written — so it is inert today).

Rendering output is unchanged (byte-identical), so no visual regression test
is added. Unit tests cover hit reuse, the 100-char skip boundary, LRU eviction
order, and the under-cap no-op.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remove MAX_CACHED_TEXT_LENGTH and the >100-char skip guard. The idle LRU
eviction already bounds the cache regardless of entry size, so the length
skip is redundant — long unique strings simply age out as least-recently-used
instead of being refused entry. This also lets long strings that *do* repeat
(e.g. a description shown across multiple nodes) benefit from caching.

Update the renderer option docs and tests accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The idle block now calls stage.cleanupTextRenderers(); add it to the mocked
stage in the out-of-memory render-loop test so the idle path doesn't throw.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@chiefcll chiefcll force-pushed the feat/sdf-text-cache-lru branch from 0770e80 to 7b9ad02 Compare June 4, 2026 20:56
@chiefcll chiefcll merged commit 2f5e7a0 into main Jun 4, 2026
1 check passed
@chiefcll chiefcll deleted the feat/sdf-text-cache-lru branch June 4, 2026 21:19
chiefcll added a commit that referenced this pull request Jun 4, 2026
…troy (#56)

Two lifecycle leaks that accumulate over a long-lived session (embedded
device, page never reloaded) as nodes/textures are created and destroyed
during navigation:

1. Shader value cache never released on node destroy.
   CoreShaderManager.valuesCache/valuesCacheUsage are keyed by resolved shader
   props + node size. Usage is incremented when values are computed and only
   decremented in the shader node's update() when the key changes, so the idle
   cleanup() (which evicts entries at usage <= 0) could never reclaim a
   destroyed node's last entry — it stayed pinned at usage >= 1 forever. This
   affects nearly every built-in shader (RoundedRectangle, Border, Shadow,
   gradients, ...).
   Fix: move the held `valueKey` onto the base CoreShaderNode and add
   detachNode(), which decrements its usage and detaches from the node.
   CoreNode.destroy() calls it for non-default (per-node) shaders.

2. SubTexture never unsubscribed from its parent atlas.
   SubTexture attaches 4 listeners (loading/loaded/failed/freed) to the parent
   texture, which is typically a long-lived shared atlas (preventCleanup), but
   had no destroy() override — so every destroyed SubTexture (and everything
   its handlers captured) was retained by the parent's listener lists.
   Fix: SubTexture.destroy() off()s the four handlers; Texture.destroy() now
   also removeAllListeners() defensively.

Found via a memory-leak audit prompted by the SDF text-cache fix (#55).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant